/*
 * Copyright (C) 2022 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.android.tools.idea.codenavigation

import com.android.tools.analytics.UsageTracker
import com.android.tools.analytics.withProjectId
import com.google.wireless.android.sdk.stats.AndroidProfilerEvent
import com.google.wireless.android.sdk.stats.AndroidStudioEvent
import com.google.wireless.android.sdk.stats.ResolveComposeTracingCodeLocationMetadata
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.fileEditor.OpenFileDescriptor
import com.intellij.openapi.project.Project
import com.intellij.pom.Navigatable
import com.intellij.psi.PsiClassOwner
import com.intellij.psi.PsiFile
import com.intellij.psi.PsiManager
import com.intellij.psi.search.FilenameIndex
import com.intellij.psi.search.GlobalSearchScope
import org.apache.maven.artifact.versioning.ComparableVersion
import org.apache.maven.model.io.DefaultModelReader
import org.jetbrains.annotations.VisibleForTesting
import java.io.File

/**
 * Navigation source for sections generated by Compose Tracing
 *
 * @param mavenSignatureResolver returns library signature { groupId, artifactId, version } for a given PsiFile
 * or `null` if it cannot resolve the signature
 */
class ComposeTracingNavSource @VisibleForTesting internal constructor(
  private val getFilesByName: (fileName: String) -> List<PsiFile>,
  private val createNavigatable: (files: List<PsiFile>, lineNumber: Int) -> Navigatable?,
  private val mavenSignatureResolver: (file: PsiFile) -> LibrarySignature? = { null },
  private val project: Project? = null
) : NavSource {
  constructor(project: Project) : this(
    getFilesByName = { fileName ->
      val psiManager = PsiManager.getInstance(project)
      FilenameIndex.getVirtualFilesByName(fileName, GlobalSearchScope.allScope(project)).mapNotNull { psiManager.findFile(it) }
    },
    createNavigatable = { files, lineNumber ->
      if (files.isEmpty()) null
      else MultiNavigatable(files.map { OpenFileDescriptor(project, it.virtualFile, lineNumber, -1) })
    },
    mavenSignatureResolver = ::pomFileLookupMavenSignatureResolver,
    project = project
  )

  // Field exists solely for testing (not used for any other reason)
  @VisibleForTesting
  var lastMetricsEvent: AndroidStudioEvent.Builder? = null
    private set;

  // TODO(b/244437735): use Psi nav sources (e.g. PsiOuterClassAndLine) as a reference to use the PsiManager instead of enumerating files
  //  manually
  //
  // TODO(b/244437735): sorting when multiple matches, e.g. prioritise user code when handling multiple matches
  override fun lookUp(location: CodeLocation, arch: String?): Navigatable? {
    val fullComposableName = location.fullComposableName ?: return null // not a Compose Tracing location
    val fileName = location.fileName ?: return null // not a Compose Tracing location
    val lineNumber = location.lineNumber

    var resolvedLocations = 0
    try {
      val result = getFilesByName(fileName) // Find files matching required file name
        .filterIsInstance<PsiClassOwner>() // We need PsiClassOwner, which provides package name
        .filter { fullComposableName.startsWith(it.packageName) } // Filter to files where package name is a prefix to fullComposableName
        .filter { it.text.lineSequence().count() >= lineNumber } // Only keep files where the required line number exists
        .filterByMaxPackageNameLength() // Filter to files with max package name length (prefix to fullComposableName test done above)
        .filterByMaxLibraryVersion(mavenSignatureResolver) // Deduplicate results when multiple versions of the same library returned
      resolvedLocations = result.size

      // Return all files that pass the filtering above
      return createNavigatable(result, lineNumber)
    }
    catch (e: Exception) {
      logger.warn("Issue while trying to navigate to location: fullComposableName=$fullComposableName, fileName=$fileName, " +
                  "lineNumber=$lineNumber.", e)
      return null
    } finally {
      UsageTracker.log(
        AndroidStudioEvent.newBuilder()
          .withProjectId(project)
          .setKind(AndroidStudioEvent.EventKind.ANDROID_PROFILER)
          .setAndroidProfilerEvent(
            AndroidProfilerEvent.newBuilder()
              .setType(AndroidProfilerEvent.Type.RESOLVE_COMPOSE_TRACING_CODE_LOCATION)
              .setResolveComposeTracingCodeLocationMetadata(
                ResolveComposeTracingCodeLocationMetadata.newBuilder().setResultCount(resolvedLocations))
          ).also { lastMetricsEvent = it }
      )
    }
  }

  companion object {
    private val logger by lazy { Logger.getInstance(ComposeTracingNavSource::class.java) }

    /** Filters out files with package name shorter than the longest one */
    @VisibleForTesting
    internal fun List<PsiClassOwner>.filterByMaxPackageNameLength(): List<PsiClassOwner> {
      val input = this
      if (input.size < 2) return input
      val maxLen = input.maxOf { it.packageName.length }
      return input.filter { it.packageName.length == maxLen }
    }

    /**
     * Removes files that are present in multiple versions of the same library (keeps newest).
     *
     * Determines the version by looking up the library POM file. Does not perform filtering on files where it cannot find a POM file.
     */
    @VisibleForTesting
    internal fun List<PsiClassOwner>.filterByMaxLibraryVersion(
      mavenSignatureResolver: (PsiFile) -> LibrarySignature?
    ): List<PsiClassOwner> {
      val input = this
      if (input.size < 2) return input

      val fileSignaturePairs = input.map { it to mavenSignatureResolver(it) }

      val filteredFiles = fileSignaturePairs
        .mapNotNull { (f, s) -> if (s == null) null else f to s }
        .groupBy { (_, s) -> "${s.groupId}:${s.artifactId}" }
        .mapNotNull { (_, fileSignatureList) -> fileSignatureList.maxByOrNull { (_, s) -> s.version } }
        .map { (f, _) -> f }

      val noSignatureFiles = fileSignaturePairs.filter { (_, s) -> s == null }.map { (f, _) -> f }

      val keepSet = (filteredFiles + noSignatureFiles).toSet() // use a set to determine which files to keep
      return input.filter { keepSet.contains(it) } // maintain original sort order
    }

    /**
     * Looks for a POM file associated with the provided [file] and uses it to resolve its [LibrarySignature]
     *
     * @return library signature `{ groupId, artifactId, version }` for a given PsiFile or `null` if it cannot resolve the signature
     */
    // TODO(b/244437735): consider caching given we're doing disk IO, and the mapping (file -> signature) won't change
    private fun pomFileLookupMavenSignatureResolver(file: PsiFile): LibrarySignature? {
      try {
        if (!file.virtualFile.path.contains(".jar!/")) return null
        val jarPath = file.virtualFile.path.split(".jar!")[0] + ".jar"
        val parentDir = File(jarPath).parentFile?.parentFile ?: return null
        val pomFile: File = parentDir.walkBottomUp().singleOrNull { it.extension == "pom" } ?: return null
        val model = DefaultModelReader().read(pomFile, mutableMapOf<String, Any>()) ?: return null
        return with(model) { LibrarySignature(groupId, artifactId, ComparableVersion(version)) }
      }
      catch (e: Exception) {
        logger.warn("Issue while trying to get metadata to navigate to code location in file: ${file.virtualFile.path}", e)
        return null
      }
    }
  }

  /** Wraps multiple [Navigatable] objects - e.g. when more than one source location matches search criteria */
  private class MultiNavigatable(private val navigatables: List<Navigatable>) : Navigatable {
    override fun navigate(requestFocus: Boolean) {
      // TODO(b/244437735): notify in UI that multiple files were open
      navigatables.forEach { nav -> nav.navigate(requestFocus) } // open all matching source files
      if (navigatables.size > 1) navigatables.first().navigate(requestFocus) // bring the first source file to the front
    }

    override fun canNavigate(): Boolean = navigatables.any { it.canNavigate() }
    override fun canNavigateToSource(): Boolean = navigatables.any { it.canNavigateToSource() }
  }

  @VisibleForTesting
  internal data class LibrarySignature(val groupId: String, val artifactId: String, val version: ComparableVersion)
}